組込み現場の「C++」プログラミング 明日から使える徹底入門

高木 信尚(株式会社クローバーフィールド

5.3 例外安全に気を配ろう

例外処理は,サイズ的にも時間的にも,決して小さくないオーバーヘッドを生じますが,それ以外にも考慮すべき点があります.「例外安全」がそうです.例外安全については「2.5.9 例外安全なコードを書こう」でも触れましたが,ここではもう少し詳しく解説することにします.

5.3.1 例外に対する安全性とは?

例外安全についてイメージをつかんでいただくために,具体的な例を挙げて見ていくことにしましょう.

template <typename T, typename Container>
class stack
{
public:
    void push(const T& value);
    T pop();
     …
private:
    Container c;
};

上記のサンプルコードは,別のコンテナクラスを用いてスタックを実現するためのクラステンプレートの例です.テンプレート引数Containerには,STLのvector,list,dequeあるいはユーザー定義のコンテナを指定することを想定しています(STLのstackと同じです).pushおよびpopメンバー関数は,おそらく次のように実装することになるでしょう.

template <typename T, typename Container>
void stack<T, Container>::push(const T& value)
{
    c.push_back(value);
}
template <typename T, typename Container>
T stack<T, Container>::pop()
{
    T t(c.back());
    c.pop_back();
    return t;
}

上記のサンプルコードで定義した2つのメンバー関数のうち,pushのほうはコンテナcのpush_backを使って,末尾に要素を追加するだけであり,特に問題はありません.問題があるのはpopのほうです.問題点は,関数の定義を見るだけではなかなかわかりにくいかもしれませんので,stackクラステンプレートの使用例を見ながら説明することにします.

stack<string, vector<string> > c;
     …
string str(c.pop()); // ← ここで例外発生! 

popメンバー関数は,コンテナcの末尾の要素を取り出してから末尾の要素を削除します.そのため,関数を抜けるときには,コンテナの末尾要素はすでに失われています.そのような状況で,return t;によるコピーによって,または,上記のサンプルコードのように,popメンバー関数の返却値を別のオブジェクトにコピーすることによって,例外が発生すると,コンテナの末尾要素は永遠に失われてしまいます.C++では,例外が送出されないことが明示されていない,あらゆる関数の呼び出し,演算子の使用,そして,オブジェクトの生成によって,例外が送出される可能性があるのです.

仮に,popメンバー関数を次のように仕様変更した場合でも,事情はそれほど変わりません.

template <typename T, typename Container>
void stack<T, Container>::pop(T& value)
{
    value = c.back();
    c.pop_back();
}

上記のサンプルコードでは,値の格納先への参照を引数として渡しています.そして,コンテナの末尾要素のコピーが成功してから要素を取り除いています.しかし,今度はc.pop_back()が例外を送出するかもしれないため,そうなった場合,引数valueとして渡された元のオブジェクトが破壊されてしまいます.仮に,pop_backメンバー関数の実装の詳細をのぞいてみて,例外が発生しないことを確認したとしても,一時しのぎにはなるかもしれませんが,このpopメンバー関数の設計が誤りであることには変わりがありません.pop_backメンバー関数の実装は,つねに変更される可能性がありますし,第一,コンテナはテンプレート引数なので,実際にはどんなクラスが指定されるかわからないからです.

STLのstackクラステンプレートでは,上で説明したような状況に対応できるように,popメンバー関数は単に要素を取り除くだけの機能しか持たず,値を取り出すのはtopメンバー関数という別の手続きを用意しています.例外に対して安全なコードを書くには,複数の機能を持った関数ではなく,極力1つの機能だけを持たせる必要があります.例外に対する安全性は,関数内部の実装の詳細ではなく,設計で保障すべき問題なのです.

template <typename T, typename Container>
class stack
{
public:
    void push(const T& value);
    void pop();
    const T& top() const;
     …
};

5.3.2 3つの例外安全保障

ここで,例外安全について,少しまとめてみたいと思います.関数を例外安全にするには,次のいずれかの保障を行わなければなりません.

  • 基本的な保障: 関数が例外を送出したとしても,メモリやリソースのリークや,データ構造の矛盾が発生するようなことがない.
  • 強い保障: 関数が例外を送出した場合,関数を呼び出す直前の状態まで戻される.すなわち,関数を呼び出しは,処理が完全に行われるか,何も行われないかのいずれかの結果になる.
  • 失敗しない保障: 関数は例外を送出することがない.また,他の失敗も発生しない.組込み型の操作,たとえば,同じ型どうしのスカラ型の代入は,失敗することがない.

このように,例外安全には3段階の保障があります.先ほどのpopメンバー関数の場合,topメンバー関数を分離しないかぎり実現できなかったのは,上記のうちの強い保障です.基本的な保障であれば,元のコードでも条件を満たしていました.それでは,実際には,どんな状況でどの例外安全保障を行えばよいのでしょうか?

選択すべき保障の種類は,パフォーマンスに重大な影響が出るなど,特別な理由がないかぎり,できるだけ強い保障か失敗しない保障にすべきです.特に,デバイスドライバ,カーネル,ライブラリといった低い階層になればなるほど,例外安全性は強化すべきです.というのも,基本的な保障しかしていない関数を呼び出す場合,それより上の階層では,もはや強い保障や失敗しない保障を実現することは困難だからです.

ところで,上で紹介した3種類の保障のうち,3番目に挙げた「失敗しない保障」というのは,以前は「例外を送出しない保障」とされていました.しかし,例外を送出しなかったとしても,何らかの失敗が検出された場合には,その対応を行わなければならないという点では同じです.それが例外かどうかは,単なるシンタックス上の問題でしかありません.そういう意味では,この例外安全に関する3つの保障というのは,仮に例外処理を使わないという選択をした場合でも,配慮すべきものといえます.

5.3.3 失敗しない保障が必要な関数

例外安全な設計を行うためには,いくつかの関数は失敗しない保障の条件を満たす必要があります.典型的なものとしては,デストラクタを含めた,メモリやリソースの解放のための関数がそれに当たります.また,オブジェクトの値を交換するためのswap関数もそうです.

popメンバー関数も,要素の型が決して失敗しないswapメンバー関数(あるいは非メンバー関数)を利用できるのであれば,強い保障が可能になります.

template <typename T, typename Container>
void stack<T, Container>::pop(T& value)
{
    T t(c.back());
    c.pop_back();
    value.swap(t);
}

上記のサンプルコードで改良したpopメンバー関数では,まずは,いったんローカルなオブジェクトであるtにコンテナの末尾要素をコピーしています.仮に,その後のc.pop_back()で例外が送出されたとしても,まだvalueは操作していませんので,呼び出し前の状態に戻るだけです.そして,c.pop_back()の呼び出しが成功した後で,決して失敗することがないswapメンバー関数を用いてvalueとtの値を交換しています.valueの元の値は,tのデストラクタによって解体されます.このような状況に出くわすことは,実際によくあります.クラスを設計する際は,失敗しない保障付きのswapメンバー関数を用意するようにしましょう.

COLUMN 例外安全に対する認識は,例外処理を使わない場合にも必要

「例外安全」というと,どうしても「例外処理を使わなければ関係ない」と考えてしまいがちです.しかし,「例外を送出しない保障」が「失敗しない保障」に呼び名が変わったように,エラーをはじめとした例外的な事象の処理は,C++の文法上の機能である例外処理を使うか,エラーコードのような別の方法を使うかはシンタックスの違いでしかありません.

「失敗しない保障」に限らず,「基本的な保障」や「強い保障」についても,例外処理を使うかどうかにかかわらず,必ず配慮しなければなりません.

たとえば,先ほどのstackクラステンプレートを例外処理を使わずに書き換えた場合を考えることにします.

template <typename T, typename Container>
class stack
{
public:
    int push(const T& value)
    {
        return c.push_back(value); // ← エラーは返却値で通知する 
    }
    T pop()
    {
        T value = c.back();
        if (c.pop_back() == -1)
        {
            // 次の行は一例です
            value.set_status(error); // ← 例外の代わりに,オブジェクトにエラー状態を持たせる 
            return value;
        }
        return value;
    }
     …
private:
    Container c;
};int main()
{
    stack<string, vector<string> > c;
     …
    string str(c.pop()); // ← 安全ではない! 
}

上のコードでは,例外を送出する代わりにset_statusというメンバー関数でエラー状態をオブジェクトに設定しています.これは参考例として挙げたまでで,実際にset_statusというメンバー関数が使えるかどうかは実装次第です.

ここで,最後にc.pop()によってstrを初期化していますが,もしこの初期化に失敗した場合はどうなるでしょうか? strが正しく初期化されていないことは,get_statusのようなメンバー関数を設ければ確認することができます.しかし,c.pop()自体が成功していた場合には,スタックcの要素は削除されてしまった後であり,その値は消失してしまいます.

このように,例外安全を使わない場合でも,さらには,C++ではなくCを使う場合でも,例外安全と同様の考え方は必要になります.